iT邦幫忙

2024 iThome 鐵人賽

DAY 16
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 18

Django REST framework: 序列化器的高級技巧與最佳實踐

  • 分享至 

  • xImage
  •  

在之前的文章中我們已經有深入探討序列化器的原理,而我們今天繼續深入探討序列化器本身

程式碼:https://github.com/class83108/drf_demo/tree/ad_serializers

今天重點如下:

  • 自定義序列化器輸出
  • 序列化器的繼承與組合
  • 資料驗證
  • 上下文使用

自定義序列化器輸出

我們先來看一下原本的輸出來看看我們如何透過自定義序列化器,來讓輸出更直觀

# GET http://127.0.0.1:8000/note/workspaces/
[
    {
        "id": 10,
        "name": "DRF Project",
        "owner": 1,
        "members": [
            1
        ],
        "created_at": "2024-09-27T17:33:27.791189Z"
    },
    {
        "id": 11,
        "name": "Django Project",
        "owner": 2,
        "members": [
            2
        ],
        "created_at": "2024-09-27T17:33:27.796077Z"
    }
]
  1. owner與members會希望輸出用戶的名稱而非ID
  2. created_at的部分也希望能格式化
  3. 我們也想要知道這個Workspace底下有多少Document,以及他們各自的title

首先StringRelatedField會將具備關聯性模型中的__str__ 作為返回值,如果是多對多關係可以添加many=True

class WorkspaceSerializer(serializers.ModelSerializer):
    owner = serializers.StringRelatedField(read_only=True)
    members = serializers.StringRelatedField(many=True, read_only=True)

    class Meta:
        model = Workspace
        fields = [
            "id",
            "name",
            "owner",
            "members",
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
[
    {
        "id": 10,
        "name": "DRF Project",
        "owner": "alice",
        "members": [
            "alice"
        ],
        "created_at": "2024-09-27T17:33:27.791189Z"
    },
    {
        "id": 11,
        "name": "Django Project",
        "owner": "bob",
        "members": [
            "bob"
        ],
        "created_at": "2024-09-27T17:33:27.796077Z"
    }
]

但是同時StringRelatedField 會將欄位變成只讀屬性,因此如果想要去修改該欄位變的不可行

這時候可以建立新的欄位然後使用source 指定資料來源

class WorkspaceSerializer(serializers.ModelSerializer):

    # owner = serializers.StringRelatedField(read_only=True)
    owner_name = serializers.CharField(source="owner.username", read_only=True)
    # members = serializers.StringRelatedField(many=True, read_only=True)
    members_name = serializers.StringRelatedField(
        source="members", many=True, read_only=True
    )

    class Meta:
        model = Workspace
        fields = [
            "id",
            "name",
            # "owner",
            # "members",
            "owner_name",
            "members_name",
            "created_at",
        ]
        read_only_fields = ["id", "created_at"]
[
    {
        "id": 10,
        "name": "DRF Project",
        "owner_name": "alice",
        "members_name": [
            "alice"
        ],
        "created_at": "2024-09-27T17:33:27.791189Z"
    },
    {
        "id": 11,
        "name": "Django Project",
        "owner_name": "bob",
        "members_name": [
            "bob"
        ],
        "created_at": "2024-09-27T17:33:27.796077Z"
    }
]

我們完成剩下的需求

  • 將created_at的欄位添加format來調整輸出
  • 透過SerializerMethodField來自定義一個方法來返回輸出,注意這也會是一個只讀欄位
    • 定義方法名稱:
      • 預設為get_<field_name>
      • 或透過SerializerMethodField(method_name=xxx)指定
  • 透過to_representation改寫輸出,增加或是重新定義我們想要呈現的資料
class WorkspaceSerializer(serializers.ModelSerializer):

    # owner = serializers.StringRelatedField(read_only=True)
    owner_name = serializers.CharField(source="owner.username", read_only=True)
    # members = serializers.StringRelatedField(many=True, read_only=True)
    members_name = serializers.StringRelatedField(
        source="members", many=True, read_only=True
    )
    created_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
    document_count = serializers.SerializerMethodField()

    class Meta:
        model = Workspace
        fields = [
            "id",
            "name",
            # "owner",
            # "members",
            "owner_name",
            "members_name",
            "created_at",
            "document_count",
        ]
        read_only_fields = ["id", "created_at"]

    def create(self, validated_data):
        if isinstance(validated_data, list):
            return Workspace.objects.bulk_create(validated_data)
        return super().create(validated_data)

    def get_document_count(self, instance):
        return instance.documents.count()

    def to_representation(self, instance):
        representation = super().to_representation(instance)
        representation["document_info"] = instance.documents.values("title")
        return representation
[
    {
        "id": 10,
        "name": "DRF Project",
        "owner_name": "alice",
        "members_name": [
            "alice"
        ],
        "created_at": "2024-09-27 17:33:27",
        "document_count": 3,
        "document_info": [
            {
                "title": "DRF Serializers"
            },
            {
                "title": "DRF Views"
            },
            {
                "title": "DRF Permissions"
            }
        ]
    },
    {
        "id": 11,
        "name": "Django Project",
        "owner_name": "bob",
        "members_name": [
            "bob"
        ],
        "created_at": "2024-09-27 17:33:27",
        "document_count": 3,
        "document_info": [
            {
                "title": "Django form"
            },
            {
                "title": "Django Views"
            },
            {
                "title": "Django Admin"
            }
        ]
    }
]

這邊有一個地方還需要注意:

還記得我們昨天提到視圖類別中可以藉由self.get_queryset() 初始化queryset嗎?

我們現在的to_representation會造成N+1查詢問題,因此如果你確定要這個序列化器都必須返回這個欄位的話需要在調用時使用select_relatedprefetch_related 來優化

workspaces = Workspace.objects.prefetch_related('documents')

序列化器的繼承與組合

既然序列化器也是一種類別來定義的,那是不是又可以回到Object-oriented programming(OOP)的特性:繼承,同時我們也可以利用在Django in 2024: 淺嚐Model, Url與Template學到的知識,使用指定某個欄位為物件的方式來達到序列化器的組合,讓人對於不同序列化器之間的關係有更明確的認知

序列化器繼承

因為Workspace與Document相同的欄位有created_at,因此我們可以將其做成一個基礎的序列化器,透過abstract = True來宣告這只是一個抽象的序列化器,與之前Django in 2024: 淺嚐Model, Url與Template在Model中使用的方式相同

class BaseSerializer(serializers.ModelSerializer):
    created_at = serializers.DateTimeField(read_only=True)

    class Meta:
        abstract = True
        fields = "__all__"

class WorkspaceSerializer(BaseSerializer):
    class Meta(BaseSerializer.Meta):
        model = Workspace
        fields = ["id", "name", "owner", "members", "created_at"]

class DocumentSerializer(BaseSerializer):
    class Meta(BaseSerializer.Meta):
        model = Document
        fields = [
            "id",
            "title",
            "content",
            "workspace",
            "created_by",
            "created_at",
            "updated_at",
        ]

這時候可能會看到class Meta(BaseSerializer.Meta),那這樣abstract = True不是也會繼承過去嗎?

但其實此時只會繼承到特定欄位例如fields,例如我們改一下

class BaseSerializer(serializers.ModelSerializer):
    created_at = serializers.DateTimeField(read_only=True)

    class Meta:
        abstract = True
        fields = ["created_at"]

class WorkspaceSerializer(BaseSerializer):
    class Meta(BaseSerializer.Meta):
        model = Workspace
        # fields = ["id", "name", "owner", "members", "created_at"]

可以看到輸出的欄位的確被繼承了

[
    {
        "created_at": "2024-09-27T17:33:27.791189Z"
    },
    {
        "created_at": "2024-09-27T17:33:27.796077Z"
    }
]

序列化器組合

序列化器組合也就是在藉由多個序列化器組成一個序列化器,例如像Workspace的關係是由User模型作為owner的外鍵,而Document又有Workspace作為外鍵,這樣的關係,透過組合能夠更一目瞭然


class MemberSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "email"]

class DocumentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Document
        fields = ["id", "title"]

class WorkspaceSerializer(serializers.ModelSerializer):
    owner = MemberSerializer(read_only=True)
    members = MemberSerializer(many=True, read_only=True)
    documents = DocumentSerializer(many=True, read_only=True)

    class Meta:
        model = Workspace
        fields = ["id", "name", "owner", "members", "documents", "created_at"]

結合繼承和組合

我們也能透過剛剛組合與繼承的特性,延伸出更多的序列化器的同時,不需要自己重寫程式碼

class BaseSerializer(serializers.ModelSerializer):
    created_at = serializers.DateTimeField(read_only=True)

    class Meta:
        abstract = True
        fields = "__all__"

class MemberSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "email"]

class BaseWorkspaceSerializer(BaseSerializer):
    owner = MemberSerializer(read_only=True)

    class Meta(BaseSerializer.Meta):
        model = Workspace
        fields = ["id", "name", "owner", "created_at"]

class DocumentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Document
        fields = ["id", "title"]

class WorkspaceListSerializer(BaseWorkspaceSerializer):
    pass

class WorkspaceDetailSerializer(BaseWorkspaceSerializer):
    members = MemberSerializer(many=True, read_only=True)
    documents = DocumentSerializer(many=True, read_only=True)

    class Meta(BaseWorkspaceSerializer.Meta):
        fields = BaseWorkspaceSerializer.Meta.fields + ["members", "documents"]

來解釋一下其中的設計原理:

  • 定義BaseSerializer作為基礎序列化器
  • BaseWorkspaceSerializer繼承BaseSerializer,並且其中的owner組合MemberSerializer序列化器
  • 讓WorkspaceListSerializer直接繼承BaseWorkspaceSerializer
  • 根據需求定義出WorkspaceDetailSerializer,他繼承BaseWorkspaceSerializer的同時,根據需要輸出的欄位去組合不同的序列化器

在昨天的文章中我們提到有哪些視圖類別能夠快速建立API端點,並且依據序列化器來返回對應的資料,在不同情境下我們可以更善用這些特性

  • 多個有相似的模型需要序列化:繼承
  • 多個API端點需要提供不同級別的詳細資訊或是需要處理嵌套關係時:組合

資料的驗證

關於資料的處理在Django REST framework: 序列化器與視圖函式 開啟API之旅已經有示範過這邊就不多提,這邊介紹在處理之前的驗證流程

Django REST framework(DRF)中的驗證流程如下

  1. 欄位級別驗證(內建驗證器)
  2. validate_<field_name>() 方法
  3. validate() 方法,最後返回驗證後的數據
  4. 其中如果有錯誤的話透過拋出serializers.ValidationError來報錯

因此我們可以單獨針對特定欄位做驗證

class WorkspaceSerializer(serializers.ModelSerializer):
    def validate_name(self, value):
        if len(value) < 3:
            raise serializers.ValidationError("名稱至少需要3個字以上。")
        return value

    class Meta:
        model = Workspace
        fields = ['id', 'name', 'owner', 'members']

或是直接在validate() 方法中定義不同欄位的資料驗證

class DocumentSerializer(serializers.ModelSerializer):
    def validate(self, data):
        if len(data['title']) > len(data['content']):
            raise serializers.ValidationError("標題不能長於內容。")
        return data

    class Meta:
        model = Document
        fields = ['id', 'title', 'content', 'workspace', 'created_by']

上下文使用

Django in 2024: Django Admin二次開發,打造屬於你的後台中,我們已經體驗到了context(上下文)的美好與重要性,在DRF中我們也能在調用序列化器時,通過上下文的傳遞來返回特定資料

例如我們想要知道當前調用API的用戶,是不是擁有該Workspace

class WorkspaceDetailSerializer(BaseWorkspaceSerializer):
    members = MemberSerializer(many=True, read_only=True)
    documents = DocumentSerializer(many=True, read_only=True)
    is_owner = serializers.SerializerMethodField()

    def get_is_owner(self, instance):
        request = self.context.get("request")
        return request.user == instance.owner

    class Meta(BaseWorkspaceSerializer.Meta):
        fields = BaseWorkspaceSerializer.Meta.fields + [
            "members",
            "documents",
            "is_owner",
        ]

我們來看一下調用視圖類別

class WorkspaceDetail(GenericAPIView):
    queryset = Workspace.objects.all()
    serializer_class = WorkspaceDetailSerializer

    def get(self, request, pk):
        workspace = self.get_object()
        serializer = self.get_serializer(workspace)
        return Response(serializer.data)

然後可以看到GET的輸出確實有新增欄位is_owner

{
    "id": 11,
    "name": "Django Project",
    "owner": {
        "id": 2,
        "username": "bob",
        "email": ""
    },
    "created_at": "2024-09-27T17:33:27.796077Z",
    "members": [
        {
            "id": 2,
            "username": "bob",
            "email": ""
        }
    ],
    "documents": [
        {
            "id": 7,
            "title": "Django form"
        },
        {
            "id": 8,
            "title": "Django Views"
        },
        {
            "id": 9,
            "title": "Django Admin"
        }
    ],
    "is_owner": false
}

這時候可能有疑問,奇怪我們剛剛在調用序列化器serializer = self.get_serializer(workspace)也沒有傳遞上下文啊?

遇事不決看源始碼:

  • get_serializer中調用self.get_serializer_context()
  • self.get_serializer_context()就已經有request作為上下文了
    def get_serializer(self, *args, **kwargs):
        """
        Return the serializer instance that should be used for validating and
        deserializing input, and for serializing output.
        """
        serializer_class = self.get_serializer_class()
        kwargs.setdefault('context', self.get_serializer_context())
        return serializer_class(*args, **kwargs)
        
    def get_serializer_context(self):
        """
        Extra context provided to the serializer class.
        """
        return {
            'request': self.request,
            'format': self.format_kwarg,
            'view': self
        }

這時候可能會想,那如果我要傳遞上下文的關鍵字參數沒有在預設的get_serializer_context裡面能不能這樣寫

serializer = self.get_serializer(workspace, context={"request": request})

答案是可以,但是不建議,因為這樣同時我們覆蓋掉了其他預設值,所以正確做法應該如下

context = self.get_serializer_context()
context.update({"some_extra_data": "value"})
serializer = self.get_serializer(workspace, context=context)

今日總結

今天我們又更深入探討序列化器的領域

  • 學習到如何在避免N+1問題的情形下自定義序列化器的輸出
  • 透過OOP的繼承特性,來讓我們的序列化器提取成更精練的結構
  • 結合不同的序列化器,讓我們所有序列化器之間的關係變得更精確
  • 再次探討is_valid後資料驗證的邏輯並嘗試添加自定義的業務邏輯
  • 了解到可以使用上下文來進行動態的調整與輸出資料

參考資料

  • 驗證:https://www.django-rest-framework.org/community/3.0-announcement/#using-is_validraise_exceptiontrue

上一篇
Django REST framework: 視圖的進化之旅 - GenericAPI 到 ViewSet,從通用基礎到高層抽象
下一篇
Django REST framework: 權限基礎到角色存取控制
系列文
Django 2024: 從入門到SaaS實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言